iT邦幫忙

2026 iThome 鐵人賽

DAY 13
1

Day 12 的 plan_node 只是個空殼,聽到計畫意圖,回一句「好的,讓我們開始規劃」就沒了。今天要把這個空殼填滿:讀懂使用者的程度、可用時間、目標,真正生成一份有週數、有主題、有具體任務的學習計畫。

這是整個系統的核心功能,Day 1 的產品構想「AI 幫你排計畫」,今天第一次真正做出來。

Planner Agent 的職責

Planner 要做的事很單純:輸入使用者的狀況,輸出一份排好的計畫。

輸入需要三樣東西(都是前幾天已經存在資料庫裡的資料):

  • 學習程度:Day 7 Profile 表的 level
  • 每週可用時間:Day 7 Profile 表的 available_hours
  • 目標:使用者這次想達成什麼,例如「3 個月內考過 AWS SA 認證」

輸出是一份 4-8 週的計畫,包含:

  • 總共幾週
  • 每週的主題
  • 拆解到「第幾天要做什麼、預估幾小時」的具體任務

Prompt 設計技巧:怎麼指導本機模型生成好計畫

Day 8 提過三個 Prompt 原則,今天實際套用在生成計畫這件事上:

  • 講清楚角色和任務:明確告訴模型「你是學習教練,根據使用者狀況生成計畫」,不要只丟一句「幫我排計畫」
  • 給明確的輸出格式:透過 Pydantic 定義好的 Schema,強制模型輸出固定欄位,不會有時候用文字條列、有時候用表格
  • 給限制條件:明確講「4 到 8 週」「每週任務的預估時數總和不要超過使用者每週可用時間」,模型才不會排出使用者根本做不完的計畫

輸出格式怎麼設計

計畫的結構如果設計成「週 → 每週裡面又包一個任務清單」這種巢狀結構,本機開源模型比較容易漏欄位或格式跑掉。今天故意設計成比較「扁平」的結構:一個週主題清單,加上一個任務清單,任務清單裡每個任務自己標記「這是第幾天」,不特別分組。

LearningPlan
├── title:計畫標題
├── duration_weeks:總共幾週
├── weekly_topics:["第1週主題", "第2週主題", ...]
└── tasks:[
      {day: 1, title: "...", estimated_hours: 2},
      {day: 2, title: "...", estimated_hours: 1.5},
      ...
    ]

這樣的形狀,本機模型比較容易穩定輸出,之後要存進 Day 4 設計的 tasks 表(day、title、estimated_hours)也幾乎是直接對應,不用額外轉換。


實作步驟

步驟1:定義 Planner 要用的 Schema

檔案位置: backend/schemas.py
狀態: 修改檔案(在 Day 9 的 StudyAdvice 後面加上這段)
用途: 定義 Planner Agent 的輸出格式
依賴: pydantic

class PlanTask(BaseModel):
    """計畫裡的一項具體任務"""
    day: int = Field(gt=0, description="這是計畫開始後的第幾天,例如第5天")
    title: str = Field(description="任務標題,具體到一個可執行的動作")
    estimated_hours: float = Field(gt=0, description="預估花費的時數")


class LearningPlan(BaseModel):
    """Planner Agent 生成的完整學習計畫"""
    title: str = Field(description="這份學習計畫的標題")
    duration_weeks: int = Field(gt=0, le=8, description="計畫總共幾週,介於4到8週之間")
    weekly_topics: list[str] = Field(description="每一週的主題,清單長度要等於duration_weeks")
    tasks: list[PlanTask] = Field(description="具體任務清單,涵蓋整個計畫期間")

步驟2:寫 planner.py

檔案位置: backend/planner.py
狀態: 新增檔案
用途: Planner Agent,根據使用者狀況與目標生成結構化學習計畫
依賴: langchain-ollama, schemas

from langchain_ollama import ChatOllama
from schemas import LearningPlan

_llm = ChatOllama(model="llama3.1:8b", temperature=0.3)
_structured_llm = _llm.with_structured_output(LearningPlan).with_retry(
    stop_after_attempt=3
)


def generate_plan(
    level: str, available_hours: float, learning_topic: str, goal_title: str
) -> LearningPlan:
    """根據使用者的程度、可用時間、想學的主題與目標,生成一份結構化學習計畫"""
    prompt = (
        "你是一位學習教練,請根據以下資訊生成一份學習計畫:\n"
        f"- 學習程度:{level}\n"
        f"- 每週可用時間:{available_hours} 小時\n"
        f"- 想學的主題:{learning_topic}\n"
        f"- 目標:{goal_title}\n\n"
        "請生成 4 到 8 週的計畫,每週訂一個主題,並拆出具體的每日任務。"
        "每週所有任務的預估時數加總,不要超過使用者每週可用時間。"
    )
    return _structured_llm.invoke(prompt)

步驟3:先獨立測試 Planner,不透過 API

檔案位置: backend/test_planner.py
狀態: 新增檔案
用途: 驗證 Planner Agent 能生成格式正確的結構化計畫
依賴: planner

from planner import generate_plan


def main() -> None:
    plan = generate_plan(
        level="初級",
        available_hours=10.0,
        learning_topic="AWS Solutions Architect 認證",
        goal_title="3個月內通過AWS SA認證考試",
    )

    print(f"計畫標題:{plan.title}")
    print(f"總共 {plan.duration_weeks} 週\n")

    print("每週主題:")
    for i, topic in enumerate(plan.weekly_topics, start=1):
        print(f"  第{i}週:{topic}")

    print(f"\n共 {len(plan.tasks)} 個任務,前5個:")
    for task in plan.tasks[:5]:
        print(f"  第{task.day}天:{task.title}(預估{task.estimated_hours}小時)")


if __name__ == "__main__":
    main()

執行:

python test_planner.py

這一步會實際呼叫本機模型,生成一整份計畫需要比之前的測試多花一點時間,不是卡住。應該看到 duration_weeks 介於 4 到 8 之間、weekly_topics 的數量跟 duration_weeks 對得上、tasks 清單裡每一項都有 day、title、estimated_hours。

步驟4:定義 API 的請求與回應格式

檔案位置: backend/schemas.py
狀態: 修改檔案(接續步驟1,繼續往下加)
用途: 定義 /plans/generate 端點的請求與回應格式
依賴: pydantic

class PlanGenerateRequest(BaseModel):
    """呼叫 /plans/generate 時,前端要送過來的格式"""
    user_id: int
    goal_title: str = Field(min_length=1, description="這次的學習目標")
    goal_deadline_weeks: int = Field(gt=0, le=12, description="目標期限,幾週後")


class PlanGenerateResponse(BaseModel):
    """/plans/generate 回傳的格式"""
    plan_id: int
    title: str
    duration_weeks: int
    weekly_topics: list[str]
    task_count: int

步驟5:寫 POST /plans/generate 端點

這個端點做四件事:確認使用者有學習檔案、建立 Goal、呼叫 Planner 生成計畫、把計畫和任務存進資料庫。

檔案位置: backend/main.py
狀態: 修改檔案(接續 Day 7 的內容,繼續往下加)
用途: 新增生成學習計畫的 API 端點
依賴: fastapi, sqlalchemy, schemas, models, planner

from datetime import datetime, timedelta
from schemas import PlanGenerateRequest, PlanGenerateResponse
from models import Goal, Plan, Task
from planner import generate_plan


@app.post("/plans/generate", response_model=PlanGenerateResponse)
def create_plan(
    payload: PlanGenerateRequest, db: Session = Depends(get_db)
) -> PlanGenerateResponse:
    """建立目標,呼叫 Planner 生成計畫,並把計畫與任務存進資料庫"""
    profile = db.query(Profile).filter(Profile.user_id == payload.user_id).first()
    if profile is None:
        raise HTTPException(
            status_code=404, detail="這個使用者還沒有學習檔案,請先呼叫 /users/profile 建立"
        )

    # 建立目標
    goal = Goal(
        user_id=payload.user_id,
        title=payload.goal_title,
        deadline=datetime.now() + timedelta(weeks=payload.goal_deadline_weeks),
        status="進行中",
    )
    db.add(goal)
    db.flush()  # 先取得 goal.id,還沒真的 commit

    # 呼叫 Planner 生成結構化計畫
    learning_plan = generate_plan(
        level=profile.level,
        available_hours=profile.available_hours,
        learning_topic=profile.learning_topic,
        goal_title=payload.goal_title,
    )

    # 把計畫存進 plans 表,content 欄位存整份 JSON
    plan = Plan(
        user_id=payload.user_id,
        goal_id=goal.id,
        title=learning_plan.title,
        duration_weeks=learning_plan.duration_weeks,
        status="草稿",
        content=learning_plan.model_dump(),
    )
    db.add(plan)
    db.flush()

    # 把每個任務存進 tasks 表
    for task in learning_plan.tasks:
        db.add(
            Task(
                plan_id=plan.id,
                day=task.day,
                title=task.title,
                estimated_hours=task.estimated_hours,
                status="待做",
                deadline=datetime.now() + timedelta(days=task.day),
            )
        )

    db.commit()

    return PlanGenerateResponse(
        plan_id=plan.id,
        title=plan.title,
        duration_weeks=plan.duration_weeks,
        weekly_topics=learning_plan.weekly_topics,
        task_count=len(learning_plan.tasks),
    )

db.flush() 是關鍵:Goal 和 Plan 剛 add 進去時還沒有 id(要等資料庫真的寫入才會產生),flush() 會先把目前的變更送到資料庫、取得自動產生的 id,但還不會真正 commit,所以如果後面任何一步出錯,整個交易還是可以一起回滾,不會存進一半的髒資料。

步驟6:測試

進入 http://127.0.0.1:8000/docs,找到 POST /plans/generate,按「Try it out」,輸入(假設 Day 5 的測試用戶 user_id 是 1,且已經完成 Day 7 建立過學習檔案):

{
  "user_id": 1,
  "goal_title": "3個月內通過AWS Solutions Architect Associate認證",
  "goal_deadline_weeks": 12
}

按「Execute」,這次呼叫要跑完整個 Planner 流程,可能需要 10-30 秒。應該收到類似這樣的回應:

{
  "plan_id": 1,
  "title": "AWS SA 認證準備計畫",
  "duration_weeks": 6,
  "weekly_topics": ["EC2 與網路基礎", "S3 與儲存服務", "資料庫與 RDS", "安全與 IAM", "架構設計原則", "模擬考複習"],
  "task_count": 24
}

再用 GET /plans/count(Day 6 寫的)確認資料庫裡的計畫數量真的增加了。


常見問題

問題1:404 這個使用者還沒有學習檔案

先用 Day 7 的 POST /users/profile 幫這個 user_id 建立學習檔案,Planner 需要 level、available_hours、learning_topic 這些資訊才能生成計畫。

問題2:duration_weeks 超出 4-8 週,或跟 weekly_topics 數量對不上

開源模型偶爾會沒完全遵守 Prompt 裡的限制。Field(gt=0, le=8) 這個驗證條件能擋掉明顯超標的情況(超過 8 週會直接被 Pydantic 拒絕、觸發重試),但如果只是 duration_weeks 跟 weekly_topics 數量對不上(例如 6 週卻只給了 5 個主題),目前的 Schema 還沒有強制檢查這件事。如果實測常常發生,可以在 LearningPlan 加一個 Pydantic 的 model_validator,檢查兩者長度是否一致,不一致就丟出驗證錯誤觸發重試。

問題3:呼叫要等很久

生成一整份 4-8 週的計畫,模型要輸出的內容量比之前幾天的範例大很多,本機模型跑起來可能要 10-30 秒甚至更久,視硬體而定,這是正常的,不是卡住。

問題4:計畫的任務時數加起來超過使用者每週可用時間

Prompt 裡已經要求「不要超過每週可用時間」,但這只是提示,不是強制驗證,模型有時候還是會抓不準。這個問題會在 Day 14 用程式邏輯(排程演算法)處理,那天會用非 AI 的方式重新檢查並調整任務分配,確保真的不超時。

問題5:為什麼 Goal 建立邏輯直接寫在 /plans/generate 裡,沒有獨立的 Goal API?

30 天計畫裡沒有安排獨立的 Goal CRUD 端點,為了先把「生成計畫」這條主線做完整,今天先讓建立目標和生成計畫合併成一個步驟。如果之後想讓使用者能單獨管理多個目標,可以再拆出獨立的 Goal API,不影響現有的資料庫設計。


進度回顧

今天把 Day 12 的空殼 plan_node 概念,做成了真正能用的功能。planner.py 能根據使用者的程度、可用時間、目標,生成一份結構化的學習計畫,POST /plans/generate 把整個流程串起來:建立目標、呼叫 Planner、把計畫和任務存進資料庫。

系統現在是這樣的:

Day 1 ✓ 產品定義完成
Day 2 ✓ 開發環境準備
Day 3 ✓ 專案架構設計
Day 4 ✓ 資料庫設計
Day 5 ✓ SQLite 資料庫建置
Day 6 ✓ FastAPI 基礎
Day 7 ✓ 使用者檔案 API
Day 8 ✓ 理解 LLM Agent 的本質
Day 9 ✓ 連接 Ollama 本機模型
Day 10 ✓ LangGraph 最小範例
Day 11 ✓ Thread 與 State 管理
Day 12 ✓ 簡化的意圖路由
Day 13 ✓ 計畫生成 Agent(今天)
Day 14 ⬜ 簡化的排程邏輯

今天生成的計畫,任務時數有沒有超過使用者可用時間,完全靠 Prompt 拜託模型自己抓。明天要用程式邏輯寫一個排程演算法,重新檢查並調整任務分配,確保計畫是真的排得進使用者的時間裡。


上一篇
Day 12:簡化的意圖路由 - 教練的「聽力」
下一篇
Day 14:簡化的排程邏輯 - 讓計畫真的排得進使用者的時間
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 16 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
tsengyulun
iT邦新手 5 級 ‧ 2026-09-28 17:30:33

版主 明天會更新嗎

pst iT邦新手 5 級 ‧ 2026-09-28 20:06:32 檢舉

等通知

我要留言

立即登入留言